一、响应式系统的核心:拦截与追踪

响应式系统的目标是当数据发生变化时,自动触发相关的副作用函数(如更新 UI)。这需要我们拦截数据的读取和修改操作,并在适当的时候触发更新。JavaScript 的 Proxy 对象为我们提供了强大的能力,可以捕获对象的所有基本操作。

1. 拦截读取操作

对于普通对象的属性读取(如 obj.foo),我们可以通过 Proxyget 拦截函数来实现:

const obj = { foo: 1 }
const p = new Proxy(obj, {
  get(target, key, receiver) {
    track(target, key) // 追踪依赖,建立响应联系
    return Reflect.get(target, key, receiver)
  }
})

这里的 track 函数负责记录当前操作的副作用函数(通常是通过 effect 函数注册的),将其与目标对象的 key 关联起来。当我们访问 p.foo 时,track 会将当前的副作用函数与 foo 属性绑定。

2. 拦截 in 操作符

in 操作符(如 'foo' in obj)也是一种读取操作。根据 ECMAScript 规范,in 操作符会调用对象的 [[HasProperty]] 内部方法,对应的 Proxy 拦截函数是 has

const p = new Proxy(obj, {
  has(target, key) {
    track(target, key)
    return Reflect.has(target, key)
  }
})

通过 has 拦截函数,我们可以在 'foo' in p 时建立副作用函数与 key 的响应联系。

3. 拦截 for...in 循环

for...in 循环依赖于 Reflect.ownKeys 获取对象的键,因此可以通过 ownKeys 拦截函数来追踪:

const ITERATE_KEY = Symbol()
const p = new Proxy(obj, {
  ownKeys(target) {
    track(target, ITERATE_KEY)
    return Reflect.ownKeys(target)
  }
})

由于 for...in 循环涉及对象的键集合,我们使用唯一的 ITERATE_KEY 作为标识,记录副作用函数与整个键集合的依赖关系。

4. 触发响应

当对象属性被修改或添加时,需要触发与该属性相关的副作用函数。set 拦截函数可以处理属性的设置操作:

const p = new Proxy(obj, {
  set(target, key, newVal, receiver) {
    const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
    const res = Reflect.set(target, key, newVal, receiver)
    trigger(target, key, type)
    return res
  }
})

trigger 函数会根据操作类型(SETADD)取出与 keyITERATE_KEY 相关的副作用函数并执行:

function trigger(target, key, type) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const iterateEffects = depsMap.get(ITERATE_KEY)
  const effectsToRun = new Set()

  effects?.forEach(effectFn => {
    if (effectFn !== activeEffect) effectsToRun.add(effectFn)
  })

  if (type === 'ADD' || type === 'DELETE') {
    iterateEffects?.forEach(effectFn => {
      if (effectFn !== activeEffect) effectsToRun.add(effectFn)
    })
  }

  effectsToRun.forEach(effectFn => {
    effectFn.options?.scheduler ? effectFn.options.scheduler(effectFn) : effectFn()
  })
}

这里我们区分了 SET(修改已有属性)和 ADD(添加新属性),因为添加或删除属性会影响 for...in 循环的执行,需要触发与 ITERATE_KEY 相关的副作用函数。


二、优化响应触发

为了让响应式系统更高效,我们需要避免不必要的副作用函数执行。

1. 避免重复触发

当设置属性值时,如果新值与旧值相同(考虑 NaN 的特殊性),应避免触发响应:

set(target, key, newVal, receiver) {
  const oldVal = target[key]
  const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
  const res = Reflect.set(target, key, newVal, receiver)
  if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
    trigger(target, key, type)
  }
  return res
}

这里通过 oldVal !== newVal 判断值是否变化,同时用 oldVal === oldVal || newVal === newVal 排除 NaN 的干扰(因为 NaN !== NaN)。

2. 处理原型链问题

当对象继承了原型属性时,访问和设置操作可能导致副作用函数重复执行。例如:

const obj = {}
const proto = { bar: 1 }
const child = reactive(obj)
const parent = reactive(proto)
Object.setPrototypeOf(child, parent)

effect(() => {
  console.log(child.bar) // 1
})
child.bar = 2 // 触发两次副作用函数

原因是访问 child.bar 会触发 childparentget 拦截,而设置 child.bar 会触发两者的 set 拦截。我们可以通过检查 receiver 是否是 target 的代理对象来避免原型引起的重复触发:

function reactive(obj) {
  const existionProxy = reactiveMap.get(obj)
  if (existionProxy) return existionProxy
  const proxy = new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') return target
      track(target, key)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, newVal, receiver) {
      const oldVal = target[key]
      const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
      const res = Reflect.set(target, key, newVal, receiver)
      if (target === receiver.raw && oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
        trigger(target, key, type)
      }
      return res
    }
  })
  reactiveMap.set(obj, proxy)
  return proxy
}

通过 target === receiver.raw 判断,只有当操作的是目标对象的代理时才触发响应,从而避免原型链的干扰。


三、深响应与浅响应

默认的 reactive 函数实现的是深响应,即嵌套对象的属性变化也会触发响应:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') return target
      if (!isReadonly && typeof key !== 'symbol') track(target, key)
      const res = Reflect.get(target, key, receiver)
      if (isShallow) return res
      if (typeof res === 'object' && res !== null) {
        return isReadonly ? readonly(res) : reactive(res)
      }
      return res
    }
  })
}

get 拦截中,如果读取的属性值是对象,则递归调用 reactive 将其包装为响应式对象。而 shallowReactive 只对第一层属性响应:

function reactive(obj) {
  return createReactive(obj)
}
function shallowReactive(obj) {
  return createReactive(obj, true)
}

只读与浅只读

只读数据(如组件的 props)需要防止修改。我们可以通过 isReadonly 参数实现:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    set(target, key, newVal, receiver) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      // ... 正常 set 逻辑
    },
    deleteProperty(target, key) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`)
        return true
      }
      // ... 正常 delete 逻辑
    }
  })
}
function readonly(obj) {
  return createReactive(obj, false, true)
}
function shallowReadonly(obj) {
  return createReactive(obj, true, true)
}

只读模式下,setdeleteProperty 会抛出警告,且不建立响应联系(get 中不调用 track)。


四、代理数组的特殊处理

数组是 JavaScript 中的异质对象,其 [[DefineOwnProperty]] 内部方法与普通对象不同。以下是数组的特殊处理:

1. 索引与 length

设置数组索引可能隐式修改 length 属性。例如,arr[1] = 'bar' 会将 length 改为 2:

set(target, key, newVal, receiver) {
  if (isReadonly) {
    console.warn(`属性 ${key} 是只读的`)
    return true
  }
  const oldVal = target[key]
  const type = Array.isArray(target)
    ? Number(key) < target.length ? 'SET' : 'ADD'
    : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
  const res = Reflect.set(target, key, newVal, receiver)
  if (target === receiver.raw && oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
    trigger(target, key, type, newVal)
  }
  return res
}

trigger 中,当操作是 ADD 且目标是数组时,触发与 length 相关的副作用函数:

function trigger(target, key, type, newVal) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  const effectsToRun = new Set()
  effects?.forEach(effectFn => {
    if (effectFn !== activeEffect) effectsToRun.add(effectFn)
  })
  if (type === 'ADD' || type === 'DELETE') {
    const iterateEffects = depsMap.get(ITERATE_KEY)
    iterateEffects?.forEach(effectFn => {
      if (effectFn !== activeEffect) effectsToRun.add(effectFn)
    })
  }
  if (type === 'ADD' && Array.isArray(target)) {
    const lengthEffects = depsMap.get('length')
    lengthEffects?.forEach(effectFn => {
      if (effectFn !== activeEffect) effectsToRun.add(effectFn)
    })
  }
  if (Array.isArray(target) && key === 'length') {
    depsMap.forEach((effects, key) => {
      if (key >= newVal) {
        effects.forEach(effectFn => {
          if (effectFn !== activeEffect) effectsToRun.add(effectFn)
        })
      }
    })
  }
  effectsToRun.forEach(effectFn => {
    effectFn.options?.scheduler ? effectFn.options.scheduler(effectFn) : effectFn()
  })
}

2. 遍历数组

for...in 遍历数组时,使用 length 作为追踪键:

ownKeys(target) {
  track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
  return Reflect.ownKeys(target)
}

for...of 遍历依赖 Symbol.iterator,数组的迭代器会访问 length 和索引,因此无需额外处理即可响应。

3. 数组查找方法

数组的 includesindexOf 等方法可能因代理对象不同导致问题。例如:

const obj = {}
const arr = reactive([obj])
console.log(arr.includes(arr[0])) // true
console.log(arr.includes(obj)) // false

原因是 arr[0] 返回代理对象,而 includes 内部获取的也是代理对象,但两者不是同一个对象。解决方案是缓存原始对象到代理的映射,并重写 includes

const reactiveMap = new Map()
function reactive(obj) {
  const existionProxy = reactiveMap.get(obj)
  if (existionProxy) return existionProxy
  const proxy = createReactive(obj)
  reactiveMap.set(obj, proxy)
  return proxy
}

const arrayInstrumentations = {}
;['includes', 'indexOf', 'lastIndexOf'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function(...args) {
    let res = originMethod.apply(this, args)
    if (res === false || res === -1) {
      res = originMethod.apply(this.raw, args)
    }
    return res
  }
})

4. 隐式修改长度的方法

pushpop 等方法会修改 length,可能导致无限递归。我们通过 shouldTrack 标志避免追踪 length

let shouldTrack = true
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach(method => {
  const originMethod = Array.prototype[method]
  arrayInstrumentations[method] = function(...args) {
    shouldTrack = false
    let res = originMethod.apply(this, args)
    shouldTrack = true
    return res
  }
})

function track(target, key) {
  if (!activeEffect || !shouldTrack) return
  // ... 正常 track 逻辑
}

五、代理 Set 和 Map

SetMap 是集合类型,操作方式与普通对象不同。我们需要重写其方法(如 addset)来实现响应式。

1. 修复 this 指向

Set.prototype.size 和方法(如 add)的 this 必须指向原始对象:

function createReactive(obj, isShallow = false, isReadonly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (key === 'raw') return target
      if (key === 'size') {
        track(target, ITERATE_KEY)
        return Reflect.get(target, key, target)
      }
      return target[key].bind(target)
    }
  })
}

2. 重写集合方法

通过 mutableInstrumentations 重写 addset 等方法:

const mutableInstrumentations = {
  add(key) {
    const target = this.raw
    const hadKey = target.has(key)
    const res = target.add(key)
    if (!hadKey) trigger(target, key, 'ADD')
    return res
  },
  set(key, value) {
    const target = this.raw
    const had = target.has(key)
    const oldValue = target.get(key)
    const rawValue = value.raw || value
    target.set(key, rawValue)
    if (!had) {
      trigger(target, key, 'ADD')
    } else if (oldValue !== value || (oldValue === oldValue && value === value)) {
      trigger(target, key, 'SET')
    }
  }
}

3. 避免数据污染

设置值时需检查是否为响应式数据,若是则使用原始数据:

const rawValue = value.raw || value
target.set(key, rawValue)

4. 处理 forEach 和迭代器

forEach 和迭代器方法(如 entries)需要包装参数为响应式数据,并与 ITERATE_KEY 建立联系:

const mutableInstrumentations = {
  forEach(callback, thisArg) {
    const wrap = val => typeof val === 'object' && val !== null ? reactive(val) : val
    const target = this.raw
    track(target, ITERATE_KEY)
    target.forEach((v, k) => callback.call(thisArg, wrap(v), wrap(k), this))
  },
  [Symbol.iterator]: iterationMethod,
  entries: iterationMethod
}

function iterationMethod() {
  const target = this.raw
  const itr = target[Symbol.iterator]()
  const wrap = val => typeof val === 'object' && val !== null ? reactive(val) : val
  track(target, ITERATE_KEY)
  return {
    next() {
      const { value, done } = itr.next()
      return {
        value: value ? [wrap(value[0]), wrap(value[1])] : value,
        done
      }
    },
    [Symbol.iterator]() {
      return this
    }
  }
}

对于 keys 方法,使用 MAP_KEY_ITERATE_KEY 避免不必要的触发:

const MAP_KEY_ITERATE_KEY = Symbol()
function keysIterationMethod() {
  const target = this.raw
  const itr = target.keys()
  const wrap = val => typeof val === 'object' ? reactive(val) : val
  track(target, MAP_KEY_ITERATE_KEY)
  return {
    next() {
      const { value, done } = itr.next()
      return { value: wrap(value), done }
    },
    [Symbol.iterator]() {
      return this
    }
  }
}